Skip to content

Carry dynamic-extent declarations from bytecode into BIR - #1821

Draft
dg1sbg wants to merge 2 commits into
clasp-developers:mainfrom
dg1sbg:pr/dynamic-extent
Draft

Carry dynamic-extent declarations from bytecode into BIR#1821
dg1sbg wants to merge 2 commits into
clasp-developers:mainfrom
dg1sbg:pr/dynamic-extent

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Draft: blocked on two Cleavir changes. This branch does not build against
s-expressionists/Cleavir main as it stands — see Dependency below. Opening it now so the
Clasp side is reviewable; it will go green on its own once the Cleavir side lands, with no
change needed here.

A (declare (dynamic-extent g)) on a variable bound to a closure was parsed and then discarded, so it never reached the compiler. This wires it through and lets the closure extent analysis act on it.

Where the declaration was lost

Clasp does not use Cleavir's CST-to-AST front end — CLEAVIR-CST-TO-AST, CLEAVIR-AST and CLEAVIR-AST-TO-BIR are not present in the image at all. Clasp goes source → bytecode → BIR, so the declaration has to travel through the bytecode, and it already does: cmp/cmpltv.lisp packs a dynamic-extent bit into the debug-var flags byte, and compile-bytecode.lisp's start-annotation already reads (core:bytecode-debug-var/decls bdv) to recover the declared type. The bit simply was not passed on.

This reads cl:dynamic-extent out of that same list and sets it on the bir:leti that binds the variable, which is where BIR's own docstring says a dynamic-extent declaration belongs — the declaration constrains the extent of the value bound rather than of the variable.

Permission, not proof

The declaration only permits the compiler to try. The escape analysis still has to succeed independently, so a declaration that is wrong costs the optimisation rather than memory safety. Two deliberately-wrong cases were checked: a local function that returns the closure, and one that stores it in a global. Both keep the closure heap-allocated and both still work when called afterwards.

Effect

A closure passed to a capturing flet called from three sites, 100000 calls: 544 → 504 bytes per call, results unchanged. The 40 bytes are the closure moving to the stack.

It only fires when the local function captures something. A non-capturing flet compiles to a constant function, so the call's callee is a constant reference with no BIR function behind it to analyse.

Dependency

Needs both of these in Cleavir:

  • Only mark a closure dynamic-extent once every reader has been checked — a soundness fix, independent of this feature. determine-closure-extent marked an enclose :dynamic as soon as one reader turned out to be a DX call; a later escaping reader bailed out without undoing it. Because Cleavir sets are EQ hash tables the iteration order varies, so compiling an escaping closure 300 times stack-allocated it 154 times, and calling one gives EXT:BUS-ERROR.
  • Let a dynamic-extent declaration reach the closure extent analysis — adds the bir:leti :dynamic-extent slot this branch writes to, plus the escape analysis that consumes it.

Without the second one, build:insert 'bir:leti :dynamic-extent … is an initarg no slot accepts, so this does not merely fail a test — the tree does not build. repos.sexp tracks Cleavir main unpinned, so once both land upstream this branch picks them up with no change here.

Tests

Two regression tests, neither of which needs the optimisation to fire in order to be meaningful:

  • a wrong declaration forfeits the optimisation rather than handing back a closure whose frame is gone
  • a correct declaration does not change the value computed

The companion test for the Cleavir soundness fix compiles an escaping closure 30 times and calls each result, since a single compile is a coin flip.

dg1sbg added 2 commits August 3, 2026 10:53
Compiles an escaping closure with the native compiler 30 times and calls
each result. Before the companion Cleavir fix the closure was stack
allocated on roughly half the compiles -- the reader iteration order that
decides it comes from an EQ hash table -- so one compile is not a reliable
probe, while 30 makes a miss effectively impossible.

This needs the DETERMINE-CLOSURE-EXTENT fix in Cleavir. repos.sexp tracks
Cleavir's main unpinned, so the test goes green once that lands upstream.
Clasp does not use Cleavir's CST-to-AST front end -- CLEAVIR-CST-TO-AST,
CLEAVIR-AST and CLEAVIR-AST-TO-BIR are not present at runtime at all --
so the declaration reaches BIR by way of the bytecode instead. It is
already carried there: CMPLTV encodes a dynamic-extent bit into the
debug-var flags byte, and START-ANNOTATION already reads
BYTECODE-DEBUG-VAR/DECLS to recover the declared type. It was simply not
passed on.

Read CL:DYNAMIC-EXTENT out of that same list and set it on the BIR:LETI
binding the variable. Cleavir's closure extent analysis then treats it as
permission to attempt a stack allocation, which it still has to prove.

Measured on a closure passed to a capturing FLET called from three sites,
100000 calls: 544 bytes per call before, 504 after, results unchanged.
The 40 bytes are the closure itself moving to the stack. It only fires
when the local function captures something; a non-capturing FLET is a
constant function, so the callee is a constant reference with no BIR
function behind it to walk into.

Also adds two regression tests: one pins the safety property, that a
declaration which is wrong forfeits the optimisation rather than handing
back a closure whose frame is gone, and one pins that a correct
declaration does not change the value computed.
@dg1sbg

dg1sbg commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Blocked on two Cleavir PRs, now open:

Without #35 this does not merely fail a test: build:insert 'bir:leti :dynamic-extent … is an initarg no slot accepts, so the tree does not build. repos.sexp tracks Cleavir main unpinned, so once both land this branch picks them up with no change here and can come out of draft.

@dg1sbg

dg1sbg commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up on this draft: I measured it on x86-64 Linux (LLVM 18, boehmprecise) and the declaration currently has no effect — the green CI here means "does not crash", not "works".

Probe: a closure declared dynamic-extent, compiled natively, versus an identical closure with no declaration; allocation measured per call over 200k calls.

upstream/main (fc2beb4) this PR (15922f2)
function type after (compile ...) SIMPLE-CORE-FUN SIMPLE-CORE-FUN
closure with (declare (dynamic-extent f)) 40.0 B/call 40.0 B/call
closure without 40.0 B/call 40.0 B/call

Identical in every cell — the closure is heap-allocated either way.

I checked the obvious way this measurement could be wrong: that the probe never reached the native compiler, which would make both readings 40.0 trivially. It does reach it — the function starts as BYTECODE-SIMPLE-FUN and ends as SIMPLE-CORE-FUN in both arms, so compile-bytecode.lisp (the only file this PR touches) is genuinely on the path.

The reason looks structural: on Cleavir main, bir:leti is

(defclass leti (writevar) () ...)

with no slots and no dynamic-extent initarg, while build:insert is (apply #'make-instance datum initargs) (BIR-builder/builder.lisp:99,113). So the :dynamic-extent initarg this PR passes has nowhere to land and is being absorbed somewhere in the initialize-instance chain. That is consistent with this being blocked on s-expressionists/Cleavir#35, which adds the BIR-side support.

Worth being aware that the three new tests pass vacuously in this configuration: they assert an escaping dynamic-extent closure remains valid and that a wrong declaration is safe, both of which are trivially true when nothing is ever stack-allocated. A build that ignores the declaration entirely passes them perfectly. They will only be meaningful once the Cleavir side lands, and it would be worth adding a positive test that asserts a non-escaping declared closure actually stops allocating — otherwise there is no test that can distinguish "working" from "inert".

Full regression suite on this branch is clean: 1966 successes, exit 0 (1963 baseline + the 3 new tests).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant